Tarea 3: Clasificación de texto con RNN

CC6204 - Deep Learning


Cuerpo Docente:¶

  • Profesor: Iván Sipiran
  • Ayudantes:
    • Camila Figueroa Acevedo
    • Gustavo Santelices
    • Sofia Capibara Chávez Bastidas
    • Victor Faraggi V. ### Estudiante:
  • Maximiliano Varas González (maximilianovarasg@gmail.com)

En esta tarea van a crear una red neuronal que clasifique mensajes como spam o no spam. Lo primero es descargar la data:

In [1]:
!mkdir data
!python -m wget https://www.ivan-sipiran.com/downloads/spam.csv
!move *.csv data/
Ya existe el subdirectorio o el archivo data.
Saved under spam.csv
C:\Users\mvarasg\Documents\Universidad\CC6204 - Deep Learning\Tareas\T3 - RNN, Embedding y LSTM\spam.csv
Se han movido         1 archivos.

Los datos vienen en un archivo CSV que contiene dos columnas "text" y "label". La columna "text" contiene el texto del mensaje y la columna "label" contiene las etiquetas "ham" y "spam". Un mensaje "ham" es un mensaje que no se considera spam.

Tarea¶

El objetivo de la tarea es crear una red neuronal que clasifique los datos entregados. Para lograr esto debes:

  • Implementar el pre-procesamiento de los datos que creas necesario.
  • Particionar los datos en 70% entrenamiento, 10% validación y 20% test.
  • Usa los datos de entrenamiento y valiadación para tus experimentos y sólo usa el conjunto de test para reportar el resultado final.

Para el diseño de la red neuronal puedes usar una red neuronal recurrente o una red basada en transformers. El objetivo de la tarea no es obtener el performance ultra máximo, sino entender qué decisiones de diseño afectan la solución de un problema como este. Lo que si es necesario (como siempre) es que discutas los resultados y decisiones realizadas.


Pre-procesamiento de data¶

Importando librerías:

In [2]:
import pandas as pd
import numpy as np
import random
import torch
from torch.utils.data import TensorDataset, DataLoader
import matplotlib.pyplot as plt
import plotly.express as px
import torch.nn as nn
from sklearn.metrics import confusion_matrix
from sklearn.model_selection import train_test_split
import seaborn as sns
from string import punctuation
import plotly
plotly.offline.init_notebook_mode()
import os 
# from sklearn.utils.multiclass import unique_labels
# from sklearn.metrics import classification_report


import time
SEED = 1234
random.seed(SEED)
np.random.seed(SEED)
torch.manual_seed(SEED)
torch.cuda.manual_seed(SEED)
torch.backends.cudnn.deterministic = True

Se carga la data en un DataFrame:

In [3]:
data = pd.read_csv('./data/spam.csv')
print(f'Las dimensiones del Dataframe son: {data.shape}.')
Las dimensiones del Dataframe son: (5572, 2).

Veamos si hay elementos nulos en ambas columnas

In [4]:
data.isna().sum()
Out[4]:
text     1
label    1
dtype: int64

En efecto, hay al menos una entrada en la columna text y en label.

In [5]:
data[data.isna().any(axis=1)]
Out[5]:
text label
2115 Well I wasn't available as I washob nobbing wi... NaN
3035 NaN -) ok. I feel like john lennon.

Hay elementos NaN en filas diferentes, por lo que se eliminan estas dos.

In [6]:
data = data.dropna()

Veamos que elementos hay en la columna label, debería solo estar "spam" y "ham".

In [7]:
# print(data['label'].unique())
print(f"En total hay {len(data['label'].unique())} distintas labels, cuando deberían ser dos.")
print(f"Las entradas distintas de 'spam' y 'ham' ({len(data[(data['label']!='spam') & (data['label']!='ham')])}) son: ")

data[(data['label']!='spam') & (data['label']!='ham')]
En total hay 43 distintas labels, cuando deberían ser dos.
Las entradas distintas de 'spam' y 'ham' (207) son: 
Out[7]:
text label
44 Great! I hope you like your man well endowed. ... #&gt
55 Do you know what Mallika Sherawat did yesterda... URL&gt
78 Does not operate after &lt #&gt
200 I sent you &lt #&gt
202 Your account has been refilled successfully by... DECIMAL&gt
... ... ...
5500 Love has one law Make happy the person you love. In the same w...
5504 Wait . I will msg after &lt #&gt
5513 Yes. Please leave at &lt #&gt
5557 No. I meant the calculation is the same. That ... #&gt
5559 if you aren't here in the next &lt #&gt

207 rows × 2 columns

Se procede a eliminar los elementos que no tiene bien la etiqueta.

In [8]:
data = data[(data['label'] == 'spam') | (data['label'] == 'ham')]

Se procede a eliminar los signos de puntuación (por lo general no aportan al problema), entradas vacías y '\n' de las entradas. Luego, se pasa todo el texto a minúsculas.

In [9]:
print(punctuation)
data['text'] = data['text'].str.lower()
data['text'] = data['text'].str.replace(f'[{punctuation}]', '', regex=True)
data = data[data['text']!=' '] #Se eliminan elementos con strings vacíos.
data['text'] = data['text'].str.replace('\n', ' ')
print(f'Al eliminar elementos NaN y vacíos la nueva dimensión de data es: {data.shape}.')
!"#$%&'()*+,-./:;<=>?@[\]^_`{|}~
Al eliminar elementos NaN y vacíos la nueva dimensión de data es: (5361, 2).

Se pasan todas las palabras de la columna text a una lista y se hace un split de esta para tener todas las palabras:

In [10]:
lista_words = data['text'].tolist()
lista_words = [word for instancia in lista_words for word in instancia.split()]
print(f'En total se tienen {len(lista_words)} palabras.')
En total se tienen 81325 palabras.

Se crea el vocabulario para utilizar más adelante con el Embedding.

In [11]:
from collections import Counter

counts = Counter(lista_words) #Diccionario donde la llave es la palabra y el valor es la frecuencia.
vocab = sorted(counts, key=counts.get, reverse=True) #Lista de palabras, ordenadas de mayor a menor.
vocab_to_int = {word: ii for ii, word in enumerate(vocab, 1)} #Diccionario con palabra como key y la posición de las más frecuentes como value.
print('Palabras únicas:', len(vocab_to_int))
Palabras únicas: 9328

Se agrega una nueva columna con un representación de lista de números del textp. Se utiliza el vocabulario.

In [12]:
data['text_num'] = data['text'].apply(lambda x: [vocab_to_int.get(word, word) for word in x.split()]) #Nueva columna con la lista con la representación numerica de cada palabras.
data.head(5)
Out[12]:
text label text_num
0 go until jurong point crazy available only in ... ham [46, 436, 4264, 773, 728, 729, 64, 8, 1194, 88...
1 ok lar joking wif u oni ham [48, 301, 1356, 418, 6, 1782]
2 free entry in 2 a wkly comp to win fa cup fina... spam [45, 437, 8, 22, 4, 730, 871, 1, 175, 1783, 11...
3 u dun say so early hor u c already then say ham [6, 217, 143, 24, 353, 2828, 6, 156, 140, 58, ...
4 nah i dont think he goes to usf he lives aroun... ham [921, 2, 50, 99, 70, 438, 1, 922, 70, 1786, 21...

Sigue reemplazar la columna label por valores numéricos. Se considera que spam:1 y ham:0.

In [13]:
data['label_encoded'] = data['label'].replace({'spam':1, 'ham':0})

Padding¶

Consiste en agregar o eliminar palabras con el fin de que la entrada a la red sea siempre la misma. Para decidir el tamaño de las secuencias de texto, se obtiene información estadística del largo de texto del dataset.

In [14]:
from statistics import median, mode
data['largo_text'] = data['text_num'].apply(len)
print("Largo promedio de las listas:", data['largo_text'].mean())
print("Mediana del largo de las listas:", median(data['largo_text']))
print("Moda del largo de las listas:", mode(data['largo_text']))
print('Max:', max(data['largo_text']))
print('Min:', min(data['largo_text']))




fig = px.histogram(data, x='largo_text', nbins=200, title='Distribución largo texto',marginal='box')

fig.show()
Largo promedio de las listas: 15.16974445066219
Mediana del largo de las listas: 12
Moda del largo de las listas: 6
Max: 171
Min: 1

En base a esto se observa que la gran mayoría de los textos tiene largo bajo, específicamente largo 6. Sin embargo, se estima que definir un largo de secuencia tan pequeño puede ser muy perjudicial, dado que gran cantidad de textos tienen más información que 6 palabras. Se considera aumentar el largo de secuencia no representa un salto sustancial en el costo de entrenamiento, siempre y cuando este valor no sea muy grande. Viendo la distribución, se decide tomar el largo de secuencia igual al upper fence (44), dado que engloba a la mayoría de datos y porque cadenas de textos más grandes son outliers o poco comunes.

Se define la siguiente estrategia:

  • Se define un tamaño para las secuencias de texto: largo_seq
  • Si el largo de una secuencia es mayor a largo_seq, se corta y se conservan las primeras largo_seq palabras.
  • Si el largo de una secuencia es menor a largo_seq, se rellena con 0's al principio y se dejan al final las palabras de la secuencia, con el fin de tener una secuencia de largo largo_seq.

Se realiza lo anterior y se agrega la lista con padding en una nueva columna del DataFrame.

In [15]:
def padding_seq(data, largo_seq):
    if isinstance(data, list):
        features = np.zeros((1, largo_seq), dtype=int)
        features[0, -len(data):] = np.array(data)[:largo_seq]
        return features
    elif isinstance(data, pd.DataFrame):
        df = data.copy()
        def padd_row(row, largo_seq):
            if row['largo_text'] > largo_seq: # Se trunca
                return row['text_num'][:largo_seq]
            elif row['largo_text'] < largo_seq: # Se rellena con 0's
                return [0] * (largo_seq - row['largo_text']) + row['text_num']
            else: # Largo igual
                return row['text_num']
        df['text_num_padded'] = df.apply(padd_row, args=(largo_seq,), axis=1)
        return df
    else:
        raise ValueError("El argumento debe ser una lista de números o un DataFrame.")

data_padding = padding_seq(data, 44)
data_padding
Out[15]:
text label text_num label_encoded largo_text text_num_padded
0 go until jurong point crazy available only in ... ham [46, 436, 4264, 773, 728, 729, 64, 8, 1194, 88... 0 20 [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, ...
1 ok lar joking wif u oni ham [48, 301, 1356, 418, 6, 1782] 0 6 [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, ...
2 free entry in 2 a wkly comp to win fa cup fina... spam [45, 437, 8, 22, 4, 730, 871, 1, 175, 1783, 11... 1 28 [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, ...
3 u dun say so early hor u c already then say ham [6, 217, 143, 24, 353, 2828, 6, 156, 140, 58, ... 0 11 [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, ...
4 nah i dont think he goes to usf he lives aroun... ham [921, 2, 50, 99, 70, 438, 1, 922, 70, 1786, 21... 0 13 [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, ...
... ... ... ... ... ... ...
5567 this is the 2nd time we have tried 2 contact u... spam [40, 9, 5, 384, 63, 38, 17, 526, 22, 185, 6, 6... 1 30 [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 40,...
5568 will ì b going to esplanade fr home ham [35, 106, 192, 73, 1, 1881, 843, 80] 0 8 [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, ...
5569 pity was in mood for that soany other suggest... ham [9325, 59, 8, 1191, 12, 19, 9326, 224, 9327] 0 9 [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, ...
5570 the guy did some bitching but i acted like id ... ham [5, 521, 108, 109, 9328, 26, 2, 4206, 57, 391,... 0 26 [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, ...
5571 rofl its true to its name ham [2509, 42, 563, 1, 42, 260] 0 6 [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, ...

5361 rows × 6 columns


Particionamiento de datos¶

Se divide en 70% para el conjunto de train, 10% para val y 20% para test.

In [16]:
proporcion_train = 0.7
proporcion_val = 0.10
proporcion_test = 0.20

# Dividimos el DataFrame en train, val y test
data_train, data_temp = train_test_split(data_padding, test_size=1 - proporcion_train, random_state=SEED)
data_val, data_test = train_test_split(data_temp, test_size=proporcion_test / (proporcion_val + proporcion_test), random_state=SEED)

# Imprime la longitud de los conjuntos para verificar la división
print("Número de ejemplos en train:", len(data_train))
print("Número de ejemplos en val:", len(data_val))
print("Número de ejemplos en test:", len(data_test))
Número de ejemplos en train: 3752
Número de ejemplos en val: 536
Número de ejemplos en test: 1073
In [17]:
print("\t\t\tFeatures:")
print("Train set: \t\t{}".format(np.array(data_train['text_num_padded'].to_list()).shape),
      "\nValidation set: \t{}".format(np.array(data_val['text_num_padded'].to_list()).shape),
      "\nTest set: \t\t{}".format(np.array(data_test['text_num_padded'].to_list()).shape))
			Features:
Train set: 		(3752, 44) 
Validation set: 	(536, 44) 
Test set: 		(1073, 44)

Se crean los dataloaders:

In [18]:
#Se pasa a tensores
train_x = torch.tensor(np.array(data_train['text_num_padded'].to_list()), dtype=torch.long)  # Usar dtype=torch.long
train_y = torch.tensor(data_train['label_encoded'].to_list(), dtype=torch.float32)

val_x = torch.tensor(np.array(data_val['text_num_padded'].to_list()), dtype=torch.long)  # Usar dtype=torch.long
val_y = torch.tensor(data_val['label_encoded'].values, dtype=torch.float32)

test_x = torch.tensor(np.array(data_test['text_num_padded'].to_list()), dtype=torch.long)  # Usar dtype=torch.long
test_y = torch.tensor(data_test['label_encoded'].values, dtype=torch.float32)

#TensorDataset
train_data = TensorDataset(train_x, train_y)
valid_data = TensorDataset(val_x, val_y)
test_data = TensorDataset(test_x, test_y)

batch_size = 10

#DataLoaders
train_loader = DataLoader(train_data, shuffle=True, batch_size=batch_size, drop_last=True, generator=torch.Generator().manual_seed(SEED))
valid_loader = DataLoader(valid_data, shuffle=True, batch_size=batch_size, drop_last=True, generator=torch.Generator().manual_seed(SEED))
test_loader = DataLoader(test_data, shuffle=False, batch_size=batch_size, drop_last=True, generator=torch.Generator().manual_seed(SEED))

Se revisa si se entrena en CPU o GPU:

In [19]:
# Chequear si tenemos GPU
train_on_gpu=torch.cuda.is_available()

if(train_on_gpu):
    print('Training on GPU.')
else:
    print('No GPU available, training on CPU.')
Training on GPU.

Se crea la arquitectura de la RNN utilizando como base la red del laboratorio 11. A grandes rasgos, consiste en un Embedding, una capa LSTM, dropout, capa fc (lineal) y una sigmoide.

In [20]:
#Creamos la red neuronal

class SpamRNN(nn.Module):
    def __init__(self, vocab_size, output_size, embedding_dim, hidden_dim, n_layers, drop_prob=0.5):
        super(SpamRNN, self).__init__()

        self.output_size = output_size
        self.n_layers = n_layers
        self.hidden_dim = hidden_dim
        
        # Capas embedding y LSTM
        self.embedding = nn.Embedding(vocab_size, embedding_dim)
        self.lstm = nn.LSTM(embedding_dim, hidden_dim, n_layers,
                            dropout=drop_prob, batch_first=True)
        
        # dropout
        self.dropout = nn.Dropout(drop_prob)
        
        # Capa lineal y sigmoide
        self.fc = nn.Linear(hidden_dim, output_size)
        self.sig = nn.Sigmoid()

    def forward(self, x, hidden):
        
        embeds = self.embedding(x)
        lstm_out, hidden = self.lstm(embeds, hidden)
                
        #Tomamos solo el último valor de salida del LSTM
        lstm_out = lstm_out[:,-1,:]
                
        # dropout y fully-connected
        out = self.dropout(lstm_out)
        out = self.fc(out)
               
        # sigmoide
        sig_out = self.sig(out)
                  
        # retornar sigmoide y último estado oculto
        return sig_out, hidden
    
    
    def init_hidden(self, batch_size):
        # Crea dos nuevos tensores con tamaño n_layers x batch_size x hidden_dim,
        # inicializados a cero, para estado oculto y memoria de LSTM
        weight = next(self.parameters()).data
        
        if(train_on_gpu):
          hidden = (weight.new(self.n_layers, batch_size, self.hidden_dim).zero_().cuda(),
                   weight.new(self.n_layers, batch_size, self.hidden_dim).zero_().cuda())
        else:
          hidden = (weight.new(self.n_layers, batch_size, self.hidden_dim).zero_(),
                   weight.new(self.n_layers, batch_size, self.hidden_dim).zero_())
        # print("Hidden size:", hidden[0].size()) 
        return hidden

Agregar el embedding permite que la red decida la representación de las palabras y LSTM permite capturar mejor la dependencia temporal de las palabras. Por otro lado, también permite combatir el problema del desvanecimiento y explosión del gradiente. Dropuot permite evitar el sobre-ajuste, la capa fc realiza la clasificación y sigmoide decide si pertenece a la clase 0 o 1.

Función para calcular tiempo transcurrido:

In [21]:
def time_elapsed(ti, tf):
    t = tf - ti
    h, m = divmod(t, 3600)
    m, s = divmod(m, 60)
    time_str = "Time Elapsed:"
    if h > 0:
        time_str += f" {int(h)} hrs" if h > 1 else f" {int(h)} hr"
    if m > 0:
        time_str += f" {int(m)} mins" if m > 1 else f" {int(m)} min"
    if s > 0:
        time_str += f" {round(s, 2)} segs" if s != 1 else f" {round(s, 2)} seg"
    print(time_str)
    return h, m, s

Se define la función para entrenar la red. Se considera un clip para limitar las magnitudes de los gradientes, early stopping para detener el entrenamiento cuando comience a sobre-ajustarse y un deepcopy para guardar el modelo con el mejor accuracy en validation.

In [22]:
import copy

def train(red, epochs, lr=0.001, batch_size=batch_size, clip=5, early_stopping = 5):
    ti = time.time()
    criterion = nn.BCELoss()
    optimizer = torch.optim.Adam(red.parameters(), lr=lr)

    # Listas para almacenar las métricas en cada época
    train_losses = []
    train_accs = []
    val_losses = []
    val_accs = []
    flag_early = 'No'
    epoch_best_model = epochs
    epoch_early = None
    
    
    min_val_loss = np.Inf
    epochs_sin_mejora = 0

    best_model_wts = copy.deepcopy(red.state_dict())  # Inicializar mejores pesos
    best_val_acc = 0.0
    best_train_acc = 0.0

    # Enviar red al GPU
    if train_on_gpu:
        red.cuda()

    red.train()

    for e in range(epochs):
        # Inicializar estado oculto
        h = red.init_hidden(batch_size)

        # Variables para calcular las métricas en cada época
        epoch_train_losses = []
        epoch_train_accs = []
        epoch_val_losses = []
        epoch_val_accs = []

        # Bucle para batchs
        for inputs, labels in train_loader:
            if train_on_gpu:
                inputs, labels = inputs.cuda(), labels.cuda()

            # Crear nuevas variables para estados ocultos
            h = tuple([each.data for each in h])

            red.zero_grad()

            # Hacer pasada forward
            output, h = red(inputs, h)
            loss = criterion(output.squeeze(), labels.float())
            loss.backward()
            # Gradient clipping
            nn.utils.clip_grad_norm_(red.parameters(), clip)
            optimizer.step()

            # Calcular precisión en entrenamiento
            pred = torch.round(output.squeeze())
            correct_tensor = pred.eq(labels.float().view_as(pred))
            accuracy = correct_tensor.sum().item() / correct_tensor.numel()
            epoch_train_accs.append(accuracy)
            epoch_train_losses.append(loss.item())

        # Calcular promedio de pérdida y precisión en entrenamiento
        mean_train_loss = np.mean(epoch_train_losses)
        mean_train_acc = np.mean(epoch_train_accs)
        train_losses.append(mean_train_loss)
        train_accs.append(mean_train_acc)

        # Validation loss
        val_h = red.init_hidden(batch_size)
        red.eval()
        for inputs, labels in valid_loader:
            val_h = tuple([each.data for each in val_h])
            if train_on_gpu:
                inputs, labels = inputs.cuda(), labels.cuda()

            output, val_h = red(inputs, val_h)
            loss = criterion(output.squeeze(), labels.float())
            epoch_val_losses.append(loss.item())

            # Calcular precisión en validación
            pred = torch.round(output.squeeze())
            correct_tensor = pred.eq(labels.float().view_as(pred))
            accuracy = correct_tensor.sum().item() / correct_tensor.numel()
            epoch_val_accs.append(accuracy)

        # Calcular promedio de pérdida y precisión en validación
        mean_val_loss = np.mean(epoch_val_losses)
        mean_val_acc = np.mean(epoch_val_accs)
        val_losses.append(mean_val_loss)
        val_accs.append(mean_val_acc)

        red.train()
        
        # Implementar early stopping
        if mean_val_loss < min_val_loss:
            min_val_loss = mean_val_loss
            epochs_sin_mejora = 0
            
        else:
            epochs_sin_mejora += 1
            

        if epochs_sin_mejora == early_stopping:
            flag_early = 'Sí'
            epoch_early = e + 1
            print(f"Época: {e + 1}/{ epochs} \t \033[91mEarly stopping {epochs_sin_mejora}/{early_stopping}\033[0m")
            print(f"Train Loss: {round(mean_train_loss,6)} \t Train Accuracy: {round(mean_train_acc,3)}")
            print(f"Val Loss: {round(mean_val_loss,6)} \t Val Accuracy: {round(mean_val_acc,3)}")
            print(7*"--------")
            print("\t\033[91mEarly Stopping! No hubo mejora del loss durante las últimas épocas.\033[0m")          
            break

        # Copiar los mejores pesos del modelo
        if mean_val_acc > best_val_acc:
            best_train_acc = mean_train_acc
            best_val_acc = mean_val_acc
            best_model_wts = copy.deepcopy(red.state_dict())
            epoch_best_model = e + 1
            
        
        

    # Imprimir métricas de la época
        print(f"Época: {e + 1}/{ epochs} \t Early stopping {epochs_sin_mejora}/{early_stopping}")
        print(f"Train Loss: {round(mean_train_loss,6)} \t Train Accuracy: {round(mean_train_acc,3)}")
        print(f"Val Loss: {round(mean_val_loss,6)} \t Val Accuracy: {round(mean_val_acc,3)}")
        print(7*"--------")

        
    # Restaurar el mejor modelo
    print(f"Se retorna el mejor modelo, con epoch: {epoch_best_model}, acc_train: {best_val_acc} y acc_val: {best_train_acc}")
    red.load_state_dict(best_model_wts)
    tf = time.time()
    h, m, s = time_elapsed(ti, tf)
    return {
        'red_entrenada': red,
        'train_losses': train_losses,
        'train_accs': train_accs,
        'val_losses': val_losses,
        'val_accs': val_accs,
        'n_epochs': epochs,
        'flag_early': flag_early,
        'epoch_early': epoch_early,
        'epoch_best_model': epoch_best_model,
    }

Se define la red dada la arquitectura y se entrena.

In [23]:
vocab_size = len(vocab_to_int) + 1 # +1 for zero padding + our word tokens
output_size = 1
embedding_dim = 100 #Se define un embedding pequeño dado que son pocas palabras en el vocabulario.
hidden_dim = 256
n_layers = 2
net = SpamRNN(vocab_size, output_size, embedding_dim, hidden_dim, n_layers)
dict_red1 = train(net, epochs = 30, lr = 0.001, batch_size = batch_size, clip = 5)
Época: 1/30 	 Early stopping 0/5
Train Loss: 0.178348 	 Train Accuracy: 0.941
Val Loss: 0.066744 	 Val Accuracy: 0.985
--------------------------------------------------------
Época: 2/30 	 Early stopping 0/5
Train Loss: 0.080198 	 Train Accuracy: 0.98
Val Loss: 0.060563 	 Val Accuracy: 0.981
--------------------------------------------------------
Época: 3/30 	 Early stopping 1/5
Train Loss: 0.056243 	 Train Accuracy: 0.985
Val Loss: 0.066484 	 Val Accuracy: 0.981
--------------------------------------------------------
Época: 4/30 	 Early stopping 0/5
Train Loss: 0.036945 	 Train Accuracy: 0.991
Val Loss: 0.036187 	 Val Accuracy: 0.987
--------------------------------------------------------
Época: 5/30 	 Early stopping 1/5
Train Loss: 0.013571 	 Train Accuracy: 0.995
Val Loss: 0.05708 	 Val Accuracy: 0.985
--------------------------------------------------------
Época: 6/30 	 Early stopping 2/5
Train Loss: 0.004873 	 Train Accuracy: 0.999
Val Loss: 0.059669 	 Val Accuracy: 0.987
--------------------------------------------------------
Época: 7/30 	 Early stopping 3/5
Train Loss: 0.014599 	 Train Accuracy: 0.997
Val Loss: 0.066135 	 Val Accuracy: 0.979
--------------------------------------------------------
Época: 8/30 	 Early stopping 4/5
Train Loss: 0.005179 	 Train Accuracy: 0.999
Val Loss: 0.125853 	 Val Accuracy: 0.972
--------------------------------------------------------
Época: 9/30 	 Early stopping 5/5
Train Loss: 0.007721 	 Train Accuracy: 0.998
Val Loss: 0.078854 	 Val Accuracy: 0.983
--------------------------------------------------------
	Early Stopping! No hubo mejora del loss durante las últimas épocas.
Se retorna el mejor modelo, con epoch: 6, acc_train: 0.9867924528301888 y acc_val: 0.9992000000000001
Time Elapsed: 36.45 segs

Función para graficar el Loss y el Accuracy en el conjunto de entrenamiento y validación en las épocas, resultante del entrenamiento.

In [24]:
def graficar(dict_model):
    train_losses = dict_model['train_losses']
    val_losses = dict_model['val_losses']
    train_accs = dict_model['train_accs']
    val_accs = dict_model['val_accs']
    n_epoch = dict_model['n_epochs']
    flag_early = dict_model['flag_early']
    epoch_early = dict_model['epoch_early']
    epoch_best_model = dict_model['epoch_best_model']
    epoch = range(1,len(train_losses)+1)

    fig, axes = plt.subplots(nrows=1, ncols=2, figsize=(12, 5))  # Dos gráficos en una fila
    axes[0].plot(epoch, train_losses, label='Train Losses', color='b')
    axes[0].plot(epoch, val_losses, label='Val Losses', color='r')
    axes[0].set_title('Función de Pérdidas')
    axes[0].set_xlabel('Épocas')
    axes[0].set_ylabel('Loss')
    axes[0].legend()
    axes[1].plot(epoch, train_accs, label='Train_Acc', color='b')
    axes[1].plot(epoch, val_accs, label='Val_Acc', color='r')


    axes[1].set_title('Accuracy')
    axes[1].set_xlabel('Épocas')
    axes[1].set_ylabel('Accuracy')
    axes[1].legend()
    if flag_early == 'No':
        plt.suptitle(f'Entrenamiento con {n_epoch} epocas, early_stopping: No, Epoch_best_model: {epoch_best_model}.')
    else:
        plt.suptitle(f'Entrenamiento con {n_epoch} epocas, early_stopping: Sí ({epoch_early}), Epoch_best_model: {epoch_best_model}.')
    best_val_acc = val_accs[epoch_best_model - 1]     
    axes[1].text(epoch_best_model, best_val_acc, f'Best Acc.: {best_val_acc:.2f}',
                 ha='left', va='bottom', fontsize=8, color='black', bbox=dict(facecolor='yellow', alpha=0.5))
    axes[1].plot(epoch_best_model, best_val_acc, marker='o', markersize=5, color='black')
    plt.tight_layout()
    plt.show()
In [25]:
graficar(dict_red1)

Los graficos muestran como cambia el Loss y el accuracy en train y val. Se puede ver como la función de loss es prácticamente decreciente para el train y en validación decrece pero tiene crecidas. Esto se debe a que la red comienza a prenderse los datos del conjunto de entrenamiento, por lo que se decidió agregar un early_stopping. Notar que la diferencia entre el accuracy de train y val es menor al 5%, por lo que se considera que hay poco sobreajuste. Por otro lado, se observa que el mejor resultado se obtiene en la época marcada en el gráfico y ese es el modelo que se guarda.

Guardar modelo:

In [26]:
sobreescribir = True
if os.path.exists('./SpamRNN.pt') and sobreescribir == 0:
    # Si el archivo existe, cargar el modelo
    print('Ya existe el modelo en la ruta, no se sobre-escribió.')
    #model = torch.load('./SpamRNN.pt')
else: #guardar 
    torch.save(dict_red1['red_entrenada'], './SpamRNN.pt')
    print('Se guardó el modelo en la ruta.')
Se guardó el modelo en la ruta.

Función para evaluar la red en el conjunto de prueba y grafica una matriz de confusión:

In [27]:
def evaluar_red(dict_red, test_loader=test_loader):
    ti = time.time()

    red = dict_red['red_entrenada']
    criterion = nn.BCELoss()

    test_losses = []  # Track loss
    num_correct = 0
    all_preds = []
    all_labels = []

    # Inicializar estado oculto
    h = red.init_hidden(batch_size)  # Asegúrate de tener la variable batch_size definida

    red.eval()
    for inputs, labels in test_loader:

        h = tuple([each.data for each in h])

        if train_on_gpu:
            inputs, labels = inputs.cuda(), labels.cuda()

        output, h = red(inputs, h)

        test_loss = criterion(output.squeeze(), labels.float())
        test_losses.append(test_loss.item())

        # Convertir probabilidades a clases (0,1)
        pred = torch.round(output.squeeze())

        # Comparar predicciones a labels
        correct_tensor = pred.eq(labels.float().view_as(pred))
        correct = np.squeeze(correct_tensor.cpu().numpy()) if train_on_gpu else np.squeeze(correct_tensor.numpy())
        num_correct += np.sum(correct)

        all_preds.extend(pred.detach().cpu().numpy())
        all_labels.extend(labels.cpu().numpy())

    # Calcular estadísticas
    test_loss_avg = np.mean(test_losses)
    test_accuracy = num_correct / len(test_loader.dataset)

    # Matriz de confusión
    cm = confusion_matrix(all_labels, all_preds, normalize='true')
    classes = list(range(len(cm)))

    # Crear un DataFrame de la matriz de confusión
    df = pd.DataFrame(cm, index=classes, columns=classes)

    # Crear un heatmap de la matriz de confusión
    g = sns.heatmap(df, annot=True, cmap="Blues")
    g.set_yticklabels(g.get_yticklabels(), rotation=0)

    plt.title('Confusion matrix \n')
    plt.xlabel('Predicted label')
    plt.ylabel('True label')
    plt.autoscale()
    plt.show()
    print(f"test_loss_avg: {round(test_loss_avg,6)}\t test_accuracy: {round(test_accuracy*100,1)}%.")
    tf = time.time()
    h, m, s = time_elapsed(ti, tf)
    return test_loss_avg, test_accuracy

loss_test, acc_test = evaluar_red(dict_red1)
test_loss_avg: 0.091165	 test_accuracy: 97.7%.
Time Elapsed: 0.46 segs

En base a esto se puede ver que los resultados son excelentes, tanto para la clase ham (0) como para spam (1). Un punto importante a considerar es que las clases están desbalanceadas, por lo que al haber menos ejempos para spam, es más difícil aprender sobre esta. Quizás si se tuvieran más entradas de esta clase se podría obtener un mejor resultado con la misma arquitectura.

El accuracy en el conjunto de test es cercano al 98% y tiene buenos resultados para ambas clases, por lo que se tiene que la red sí logró aprender y generalizar. La gran cantidad de errores se deben a la clase spam y se estima que esto es por el desbalance de clases.

Algo se que se podría explorar sería probar en el conjunto de entrenamiento a hacer DataAugmentation, haciendo padding cuando fuera necesario y cuando los textos fueran más grandes que el largo límite, se crearía un nuevo texto con lo que sobre. Se decidió no probar esto dado que los resultados son bastante buenos.

Función para tokenizar secuencias de texto:

In [28]:
def tokenize_review(test_review):
    test_review = test_review.lower() 
    test_text = ''.join([c for c in test_review if c not in punctuation])
    
    test_words = test_text.split()
    
    test_ints = []
    test_ints.append([vocab_to_int[word] for word in test_words])
    
    return test_ints

tokenize_review('hi we have an offer for u')
Out[28]:
[[104, 38, 17, 115, 374, 12, 6]]

Función para predecir si es spam o ham una secuencia de texto:

In [29]:
def predict(net, test_review, sequence_length):
      
    net.eval()
    
    test_ints = tokenize_review(test_review)
    # print(test_ints)
    
    seq_length = sequence_length
    features = padding_seq(test_ints[0], seq_length)
    
    feature_tensor = torch.from_numpy(features)
    
    batch_size = feature_tensor.size(0)
    
    h = net.init_hidden(batch_size)
    
    if(train_on_gpu):
      feature_tensor = feature_tensor.cuda()
      
    output, h = net(feature_tensor, h)
    
    pred = torch.round(output.squeeze())
    txt_print = 'Valor predicho: {:.4f}'.format(output.item())
    
    # print custom response based on whether test_review is pos/neg
    if(pred.item()==0):
      print(f'{txt_print}\t --->\t\033[94mHam\033[0m')
    else:
      print(f'{txt_print}\t --->\t\033[91mSpam\033[0m')

Ejemplo de uso:

In [30]:
predict(net,'call now to and win a gift', 44)
Valor predicho: 0.9996	 --->	Spam

Función que realiza lo anterior con un ejemplo random del dataset de test:

In [31]:
def print_mod(texto, largo = 100):
    lineas = [texto[i:i+largo] for i in range(0, len(texto), largo)]
    print('\n'.join(lineas))
In [32]:
def probar_test(df, largo_secuencia = 44):
    idx = df.index.tolist()
    random_idx = random.choice(idx)
    random_row = df.loc[random_idx]

    texto = random_row['text']
    label = random_row['label']

    # Imprimir los valores seleccionados
    print('Texto:')
    print_mod(f'"{texto}"')
    if label == "ham":
        print(f'Label: \033[94m{label}\033[0m')
    else:
        print(f'Label: \033[91m{label}\033[0m')
    
    predict(net, texto, sequence_length = largo_secuencia)
    print(14*'---------')

Ejemplo de uso:

In [33]:
for i in range(5):
    probar_test(data_test, 44)
Texto:
"almost there see u in a sec"
Label: ham
Valor predicho: 0.0002	 --->	Ham
------------------------------------------------------------------------------------------------------------------------------
Texto:
"think i might have to give it a miss am teaching til twelve then have lecture at two damn this work
ing thing"
Label: ham
Valor predicho: 0.0005	 --->	Ham
------------------------------------------------------------------------------------------------------------------------------
Texto:
"send me yettys number pls"
Label: ham
Valor predicho: 0.0001	 --->	Ham
------------------------------------------------------------------------------------------------------------------------------
Texto:
"yessura in sun tvlol"
Label: ham
Valor predicho: 0.0063	 --->	Ham
------------------------------------------------------------------------------------------------------------------------------
Texto:
"what do u want for xmas how about 100 free text messages  a new video phone with half price line re
ntal call free now on 0800 0721072 to find out more"
Label: spam
Valor predicho: 0.9999	 --->	Spam
------------------------------------------------------------------------------------------------------------------------------
In [36]:
# !jupyter nbconvert --to html CC6204_Tarea_2.ipynb

Conclusiones Generales¶

En general, se estima que se cumplieron con los objetivos de la tarea, logrando detectar spam y ham. Se comprobó la utilidad de las RNN vistas en clases y de las LSTM que aportan una mayor memoria a la red. Se evidencia que una de las partes más importantes es la limpieza del dataset, dado que casi siempre tienen datos erróneos o faltantes. Una de las partes más relevantes fue decidir el largo del padding, dado que esto puede mejorar o empeorar el desempeño del clasificador. Se utilizó un enfoque más estadístico para saber sobre el largo de los datos y tomar una decisión más informada. Por otro lado, se experimenta por primera vez con un embedding y se comprueba su utilidad y eficiencia en problemas de clasificación de texto.

Por último, se evalúa la eficacia de las redes neuronales recurrentes (RNN) en la clasificación de texto, destacando su capacidad para simplificar el proceso en comparación con las alternativas de modelos probabilísticos basados en gramática de NLP.